Skip to content

fix(webapp): write-path and appearance-control fixes for the theme work - #4756

Merged
nicktrn merged 9 commits into
samejr/appearance-theme-togglefrom
fix/appearance-write-path-correctness
Aug 21, 2026
Merged

fix(webapp): write-path and appearance-control fixes for the theme work#4756
nicktrn merged 9 commits into
samejr/appearance-theme-togglefrom
fix/appearance-write-path-correctness

Conversation

@nicktrn

@nicktrn nicktrn commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes found while reviewing #4547, stacked on that branch so they can be reviewed on their own and merged into it. One commit per fix.

Write-path correctness

Refuse account writes while impersonating. The five dashboardPreferences writers already no-op for an impersonating admin, but the three profile writers added next to them did not, and requireUserId returns the impersonated user's id. Both gates now refuse up front and say so, rather than the preference writers silently no-opping while the page reports success.

Preserve unknown keys on a full-blob write. mutateDashboardPreferences parses the JSON column, hands the result to a mutator and persists the whole object back. zod strips keys it does not declare, so a deploy that predates a preference field drops it on the next write through that path — and updateCurrentProjectEnvironmentId sits on the navigation hot path. preserveUnknownKeys re-attaches them at the write. Note this cannot help deploys already running, so it makes this the last release able to strip rather than retroactively protecting the fields added in #4547.

Scope hidden-sidebar writes to what was shown. The customize dialog builds its hidden map from the sections it can see and the write replaced hiddenItems wholesale. The profile page has no org in scope, so it resolves sections from the most-recently-updated project's org: confirming there dropped hidden ids belonging to sections that org's flags exclude. The payload now carries the ids the dialog rendered and the write only replaces those. Submissions without the list stay authoritative.

Consider both addresses when checking email ownership. The check only looked at the address the user already had; it now considers the current and submitted address together, so an org managing either one governs the change. Validation moved ahead of the check, and emailDomainOf splits on the last @.

Interaction

Revert unsaved themes, debounce contrast saves. The theme and system-theme selects stamp data-theme before the write lands. When it fails, the loader returns the value it always had — so useSystemThemeSync's effect deps are unchanged and React's vdom diff sees no change either, and nothing rewrites the attribute. The page kept rendering a theme that was never stored while the select showed the stored one. The stored pair is now re-applied explicitly, as the side menu's switcher already did. The contrast slider is debounced because Radix commits on every arrow keypress, so a keyboard user crossing the range fired one write per step.

Tick More options for themes outside the short list. The appearance submenu offers System, Light and Dark; Black and White live on the profile page. With one of those stored, every row read as unselected.

Subtraction

Drop the profile update rate limiter. It covered one of four paths that write the same column — resources.preferences.sidemenu and .favorites take unlimited authenticated writes and go through the locked read-modify-write, which is more expensive than the single narrow jsonb_set this capped. It was also what made the contrast slider unusable by keyboard. If preference writes want limiting, it belongs in one place covering all of them.

Resolve email ownership when the dialog opens. It fans out one SSO status lookup per organization the user belongs to and ran in the profile loader on every page view, purely to pick which body the dialog renders. The action re-derives it before writing either way, so the check that guards the write now has one call site instead of two.

Testing

typecheck --filter webapp and lint clean. New unit tests for preserveUnknownKeys, mergeHiddenItems and emailDomainOf; themePreference, mergeHiddenItems and ssoManagedIdentity suites pass locally (26 tests). The rest of the webapp suite needs testcontainers and is left to CI.

No changeset or .server-changes entry: everything here fixes code on the parent branch that has not shipped. The one exception worth a maintainer's call is mergeHiddenItems, which also touches the side menu's own customize path.

The limiter covered one of four paths that write the same dashboardPreferences column: resources.preferences.sidemenu and .favorites accept unlimited authenticated writes and go through the locked read-modify-write, which is more expensive than the single narrow jsonb_set this capped. The protected write is one indexed update on the caller's own row.

It was also user-visible in the wrong way: Radix Slider commits on every arrow keypress, so keyboard users hit the 20-per-minute cap partway across the contrast range. Debouncing the control is the right fix for that, and lands separately.
getDashboardPreferences feeds the full-blob writers in dashboardPreferences.server.ts: mutateDashboardPreferences parses the column, hands the result to a mutator, and persists the whole object back. zod strips unknown keys by default, so a deploy that does not know about a preference field erases it on the next write through that path - and updateCurrentProjectEnvironmentId sits on the project navigation hot path.

Passthrough makes those writers preserve fields they were not compiled against. It cannot help already-running deploys, so the four appearance fields added alongside it stay exposed until this lands; they are behind hasThemeSwitcher in the meantime.
The five dashboardPreferences writers already no-op for an impersonating admin, but the three profile writers added alongside them did not: requireUserId returns the impersonated user's id, so a support session could permanently rewrite that user's name, email and marketing-email preference.

Both gates now refuse up front and say so, instead of the preference writers silently no-opping while the page reports success.
The customize dialog builds its hidden map from the sections it can see, and the write replaced hiddenItems wholesale. The profile page has no org in scope, so it resolves sections from the most-recently-updated project's org: confirming the dialog there dropped every hidden id belonging to a section that org's feature flags exclude, un-hiding those items everywhere else.

The payload now carries the item ids the dialog rendered and the write only replaces those. Submissions without the list stay authoritative, so the side menu's own path is unchanged until it sends one.
Supersedes the passthrough approach earlier in this branch. z.object().passthrough() puts an index signature on the inferred type, which Prisma's InputJsonValue and UserWithDashboardPreferences both reject, so it did not typecheck.

preserveUnknownKeys does the same job at the one place that matters - the full-blob write inside mutateDashboardPreferences - and leaves DashboardPreferences exactly as strict as before. Scope matches passthrough: unknown keys, not new values of a declared key. Also supplies the knownItemIds parameter the previous commit destructured but never declared.
getEmailOwnership fans out one SSO status lookup per organization the user belongs to. It ran in the profile loader on every page view, purely to choose which body the edit-email dialog renders; the action re-derives it before writing either way.

It now loads from a resource route when the dialog opens, so page views that never open it cost nothing, and the check that guards the write has one call site instead of two.
The ownership check only looked at the address the user already had. It now considers the current and the submitted address together, so an organization that manages either one governs the change. Validation moved ahead of the check so the submitted domain is parsed before it is used.

emailDomainOf splits on the last @ rather than the first, and is exported so its behaviour is covered directly.
The appearance submenu offers System, Light and Dark; Black and White live on the profile page. With one of those two stored, every row read as unselected, so the menu asserted the user had no theme at all. The row that leads to them now carries the check instead.
The theme and system-theme selects stamp data-theme before the write lands. When the write fails the loader returns the value it always had, so useSystemThemeSync's effect deps are unchanged and React's vdom diff sees no change either - nothing rewrites the attribute, and the page keeps rendering a theme that was never stored while the select shows the stored one. The stored pair is now re-applied explicitly, as the side menu's switcher already did.

The contrast slider is debounced for the same reason it needed to be: Radix commits on every arrow keypress, so a keyboard user crossing the range fired one write per step. The resnap effect now waits for the debounce slot to drain so it can't undo a drag mid-flight.
@nicktrn nicktrn self-assigned this Aug 21, 2026
@changeset-bot

changeset-bot Bot commented Aug 21, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 99bb631

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 07940e78-cd75-4f5b-b7eb-77e1b344b3a6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Observability map

As of 99bb631.

19/100 over 446 measured of 464 entry points (base 19, no change)

What this PR changed

route base head now failing
/resources/account/email-ownership new not measured

FIX FIRST

  • /api/v1/projects/:projectRef/envvars (sensitive) - auth-boundary, request-context
  • /auth/sso (sensitive) - auth-boundary, request-context
  • /_app/orgs/:organizationSlug/settings/team (sensitive) - error-classification, auth-scope, request-context

AUDIT 3 of 50 sensitive mutations record an actor. 47 without one.
CONTEXT 22 of 446 entry points name a tenant on a failure path. 345 appear only here, 39 of them sensitive, in the JSON rather than the fix list.

What the score is made of
CHECKS
  error-classification  179 applicable, 102 pass,   0 sole, global without it 12
  auth-boundary          62 applicable,  57 pass,   0 sole, global without it 16
  auth-scope             19 applicable,  17 pass,   0 sole, global without it 19
  request-context       446 applicable,  22 pass, 245 sole, global without it 64
  audit-trail            50 applicable,   3 pass,   0 sole, not in the score

The score and findings here are report-only and never gate the merge. Separately, a required test suite keeps this tool's symbol and route lists in sync with the code they name, and can fail a pull request that renames or removes a symbol they reference, or that adds the first route with a segment they anticipate. Each failure names the list to edit. The rules and their reasons: internal-packages/observability-map/README.md.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@nicktrn
nicktrn merged commit b082e44 into main Aug 21, 2026
54 checks passed
@nicktrn
nicktrn deleted the fix/appearance-write-path-correctness branch August 21, 2026 18:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants